﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Net;
using System.Text.RegularExpressions;
using System.ComponentModel;
using System.IO;
using System.Reactive.Linq;
using System.Reactive.Concurrency;
using System.Windows.Threading;
using System.Windows.Media.Animation;
using System.Deployment.Application;

namespace CountJobTags {
    /// <summary>
    /// Hosts a form to download and analyze job tags from StackOverflow
    /// </summary>
    public partial class MainWindow : Window, INotifyPropertyChanged {
        private const int BAR_HEIGHT = 100;

        /// <summary>Updated when a bound property is modified</summary>
        public event PropertyChangedEventHandler PropertyChanged;

        /// <summary>Where the raw downloaded data is saved</summary>
        private const string TAG_FILE = "JobTags.txt";
                                                    
        private const string REFRESH_BTN_CAPTION = "_Refresh Data from Web";

        /// <summary>State abbreviations, used to help disambiguate the country</summary>
        private string[] STATES = { "AK", "AL", "AR", "AZ", "CA", "CO", "CT", "DC", "DE", "FL", 
                                    "GA", "HI", "IA", "ID", "IL", "IN", "KS", "KY", "LA", "MA",
                                    "MD", "ME", "MI", "MN", "MO", "MS", "MT", "NC", "ND", "NE",
                                    "NH", "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA", "RI",
                                    "SC", "SD", "TN", "TX", "UT", "VA", "VT", "WA", "WI", "WV", "WY" };

        /// <summary>Subscribes to the data being captured on separate thread for tags being read from the web</summary>
        IDisposable _TagCaptureSubscription;

        /// <summary>Holds </summary>
        List<JobDesc> _JobTagList;

        List<TagAndCount> _TagListWithCounts;
        List<TagAndCount> _AdjustableTagList;
        SolidColorBrush[] _GraphColors;

        /// <summary>Used to determine when to update the summary of what states user has selected</summary>
        private int _StateSelectionCount;

        /// <summary>Used to determine when to update the country selection summary</summary>
        private int _CountrySelectionCount;

        /// <summary>User can search repeatedly for the same thing if more than one hit, this remembers the last search criteria</summary>
        private string _LastSearch;
        
        /// <summary>Indes into the matches list for users' search</summary>
        private int _LastSearchIndex;
        
        /// <summary>Used to turn-off the animation property</summary>
        DispatcherTimer _timer;

        /// <summary>Determines behavior for the status bar when loading from file vs from web</summary>
        private bool _FetchingFromWeb = false;

        /// <summary>
        /// Delegates make it possible to pass data to the front-end UI from a background thread using Dispatcher.Invoke
        /// </summary>
        /// <param name="pageNum">Page number processed</param>
        /// <param name="pageTot">Total pages to process</param>
        delegate void FetchProgress(int pageNum, int pageTot);

        /// <summary>
        /// Allows the background thread to kick-off tag processing when done
        /// </summary>
        /// <param name="tagList"></param>
        delegate void ProcessTags(List<TagAndCount> tagList);

        /// <summary>
        /// Constructor for the form
        /// </summary>
        public MainWindow() {
            InitializeComponent();
            tabGroup.Visibility = System.Windows.Visibility.Hidden;
            _GraphColors = new [] { Brushes.Red, Brushes.Orange, Brushes.Yellow, Brushes.Green, Brushes.Blue, Brushes.Indigo, Brushes.Violet };
            _timer = new DispatcherTimer();
            _timer.Interval = TimeSpan.FromSeconds(1);
            _timer.Tick += _timer_Tick;

            if (CountJobTags.Properties.Settings.Default.FirstTime || (ApplicationDeployment.IsNetworkDeployed && ApplicationDeployment.CurrentDeployment.IsFirstRun)) {
                CountJobTags.Properties.Settings.Default.FirstTime = false;
                CountJobTags.Properties.Settings.Default.Save();
                SetupInstructionAnimation();
            } else {
                expInstructions.Header = "Show Introduction/Instructions";
                expInstructions.IsExpanded = false;
                string fName = TAG_FILE;
                if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed) {
                    fName = System.Deployment.Application.ApplicationDeployment.CurrentDeployment.DataDirectory + "\\" + TAG_FILE;
                }
                if (File.Exists(fName)) {
                    LoadFromFile(fName);
                }
            }

            _EnableAnimations = false;
        }

        private void SetupInstructionAnimation() {
            Storyboard sb = new Storyboard();
            string instructions = "This app analyzes the job skills on StackOverflow.com (Careers 2.0). Which job skills are most in demand? Can you identify the " +
                                  "most valuable skills by how long a job has been open? Use the results to wisely choose which skills you want to learn next. ";
            
            int startOffsetMs;
            StringAnimationUsingKeyFrames stringAni = BuildInstructionStringAnimation(instructions, out startOffsetMs);
            tbInstructions.BeginAnimation(TextBlock.TextProperty, stringAni);

            //now, a color animation for the background that starts after the string animation completes.
            ColorAnimation bgColorAni = BuildBackgroundColorAnimation(startOffsetMs);

            sb.Children.Add(bgColorAni);

            startOffsetMs += (int)(bgColorAni.Duration.TimeSpan.TotalMilliseconds * 2 * bgColorAni.RepeatBehavior.Count);
            DoubleAnimation shrinkAni = BuildShrinkInstructionAnimation(startOffsetMs);
            sb.Children.Add(shrinkAni);
            sb.Completed += new EventHandler(Introductory_Animation_StoryBoard_Completed);

            tbInstructions.Loaded += delegate(object sender, RoutedEventArgs e) {
                sb.Begin(this);
            };
        }

        private StringAnimationUsingKeyFrames BuildInstructionStringAnimation(string instructions, out int ms) {
            StringAnimationUsingKeyFrames stringAni = new StringAnimationUsingKeyFrames();
            ms = 0;
            KeyTime kyTime = new KeyTime();
            int interval = 150;
            int wordIndex = 0;
            wordIndex = instructions.IndexOf(' ');
            while (wordIndex > 0) {
                string aWord = instructions.Substring(0, wordIndex);
                kyTime = TimeSpan.FromMilliseconds(ms);
                if (aWord.EndsWith("?") || aWord.EndsWith(").")) {
                    ms += 1250;
                } else {
                    ms += interval;
                }
                stringAni.KeyFrames.Add(new DiscreteStringKeyFrame(aWord, kyTime));

                wordIndex = instructions.IndexOf(' ', wordIndex + 1);
            }
            stringAni.Duration = TimeSpan.FromMilliseconds(ms);
            return stringAni;
        }

        private ColorAnimation BuildBackgroundColorAnimation(int startOffsetMs) {
            ColorAnimation bgColorAni = new ColorAnimation();
            bgColorAni.BeginTime = TimeSpan.FromMilliseconds(startOffsetMs);

            bgColorAni.From = Colors.SlateBlue;
            bgColorAni.To = Colors.SlateGray;
            bgColorAni.Duration = TimeSpan.FromSeconds(1);
            bgColorAni.RepeatBehavior = new RepeatBehavior(2);

            Storyboard.SetTarget(bgColorAni, tbInstructions);
            Storyboard.SetTargetProperty(bgColorAni, new PropertyPath("Background.Color"));
            bgColorAni.AutoReverse = true;
            return bgColorAni;
        }

        private DoubleAnimation BuildShrinkInstructionAnimation(int startOffsetMs) {
            ScaleTransform scale = new ScaleTransform(1.0, 1.0);
            tbInstructions.RenderTransformOrigin = new Point(0, 0);
            tbInstructions.RenderTransform = scale;

            DoubleAnimation shrinkAni = new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(300), FillBehavior.Stop);
            shrinkAni.BeginTime = TimeSpan.FromMilliseconds(startOffsetMs + 4000);
            Storyboard.SetTargetProperty(shrinkAni, new PropertyPath("RenderTransform.ScaleY"));
            Storyboard.SetTarget(shrinkAni, tbInstructions);
            return shrinkAni;
        }

        void Introductory_Animation_StoryBoard_Completed(object sender, EventArgs e) {
            expInstructions.IsExpanded = false;
            expInstructions.Header = "Show Introduction/Instructions";

            string fName = TAG_FILE;
            if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed) {
                fName = System.Deployment.Application.ApplicationDeployment.CurrentDeployment.DataDirectory + "\\" + TAG_FILE;
            }
            if (File.Exists(fName)) {
                LoadFromFile(fName);
            }
        }

        /// <summary>
        /// Goes to Stack Overflow and downloads info on the jobs listed there
        /// </summary>
        /// <param name="sender">The button</param>
        /// <param name="e">Event Args</param>
        private void btnFetch_Click(object sender, RoutedEventArgs e) {
            try {
                Button fetchButton = (Button)sender;

                //I use the same button to start and cancel (I change the caption to 'Cancel' when running)
                //Either kick-off the fetch process, or else cancel and clean-up
                string caption = fetchButton.Content as string;
                if (caption == REFRESH_BTN_CAPTION) {
                    fetchButton.Content = "_Cancel";
                    prg.Visibility = System.Windows.Visibility.Visible;
                    _JobTagList = new List<JobDesc>();

                    //I'm using Reactive extensions, which allow you to convert a method to Observable.
                    //The method 'FetchStackOverflowJobs' retrieves job descriptions from the site and returns the results as an IObservable

                    string url = CountJobTags.Properties.Resources.stackOverflowUrl;
                    PageParser myPageParser = new PageParser {
                        EmployerAndLocationPattern = CountJobTags.Properties.Resources.reEmployerAndLocation,
                        JobIdPattern = @"\d+",
                        PagePattern  = CountJobTags.Properties.Resources.rePage,
                        PostedPattern = CountJobTags.Properties.Resources.rePosted,
                        RelocationPattern = CountJobTags.Properties.Resources.reRelocation,
                        TagsPattern = CountJobTags.Properties.Resources.reTags,
                        WorkRemotePattern = CountJobTags.Properties.Resources.reWorkRemote
                    };
                    IObservable<JobDesc> subscribable = Observable.ToObservable<JobDesc>(FetchStackOverflowJobs(url, myPageParser), Scheduler.Default);

                    _FetchingFromWeb = true;
                    //Subscribe and give it 3 methods (OnNext, OnError, OnComplete) it can use to do its work
                    _TagCaptureSubscription = subscribable.Subscribe(
                        //OnNext
                            j => {
                                _JobTagList.Add(j);
                                Dispatcher.Invoke(new FetchProgress(UpdateProgess), j.PageNumber, j.PageTotal);
                            },
                        //OnError
                            ex => MessageBox.Show(ex.Message + "\n\n" + ex.StackTrace, "Sorry - Unanticipated Error!", MessageBoxButton.OK, MessageBoxImage.Error),
                        //OnComplete
                            FetchComplete
                    );
                } else {
                    if (_TagCaptureSubscription != null) {
                        _TagCaptureSubscription.Dispose();
                    }
                    prg.Visibility = System.Windows.Visibility.Collapsed;
                    StatusText = "Fetch complete";
                    fetchButton.Content = REFRESH_BTN_CAPTION;
                }
            } catch (Exception ex) {
                MessageBox.Show(ex.Message + "\n\n" + ex.StackTrace, "Sorry - Unanticipated Error", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }

        /// <summary>
        /// Does the work of fetching a page
        /// </summary>
        /// <param name="url">URL to go to</param>
        /// <returns>A Job Description</returns>
        private IEnumerable<JobDesc> FetchStackOverflowJobs(string url, PageParser myPageParser) {
            //Regex reDiv, reAge, reTags, reLocation, rePageMax;
            //BuildRegexes(out reDiv, out reAge, out reTags, out reLocation, out rePageMax);

            int pageCount = int.MinValue;
            int pageNum = 1;

            using (WebClient wc = new WebClient()) {
                do {
                    string urlWithPageNum = string.Format(url, pageNum);
                    using (StreamReader sr = new StreamReader(wc.OpenRead(urlWithPageNum))) {

                        string allText = sr.ReadToEnd();
                        List<JobDesc> allJobs = myPageParser.ParseText(allText);

                        if (pageCount == int.MinValue && allJobs!= null && allJobs.Count > 0) {
                            pageCount= allJobs[0].PageTotal;
                        }

                        foreach (JobDesc aJob in allJobs) {
                            yield return aJob;
                        }
                    }
                } while (++pageNum <= pageCount);
            }
        }

        /// <summary>
        /// Builds regular expressions that can work with the HTML displayed on Stack Overflow
        /// </summary>
        /// <param name="reDiv">Finds the html DIV tag contents associated with a job</param>
        /// <param name="reAge">Finds the job age within the job div</param>
        /// <param name="reTags">Finds the job's tags withing the div</param>
        /// <param name="reLocation">Identifies whether the job allows working remote</param>
        /// <param name="rePageMax">Returns the max number of apges</param>
        private static void BuildRegexes(out Regex reDiv, out Regex reAge, out Regex reTags, out Regex reLocation, out Regex rePageMax) {
            //reDiv = new Regex(CountJobTags.Properties.Resources.tagRegex,
            //                  RegexOptions.IgnorePatternWhitespace | RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.Singleline);

            reDiv = new Regex(CountJobTags.Properties.Resources.jobRegex, 
                            RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.Multiline | RegexOptions.IgnoreCase);

            string agePattern = @"<p\sclass=""text.*?posted.*?"">\r?\n?\s*(.*?)\r?\n?\s*</p>";
            reAge = new Regex(agePattern, RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace);

            //Note the comments injected in the pattern below, I wrote them in-line to make it easier to included escaped quotes
            string tagPattern = "class=\"post-tag(?#Literal match)    [^>]+(?#Anything but >)    >(?#Literal)  ([^<]+)(?#Capture group, anything but <)   </a>(?#Literal)";
            reTags = new Regex(tagPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Multiline);

            //The pattern is stored as a project resource, mainly because the embedded quotes were difficult to handle propertly
            //'SingleLine' means that matches will cover more than one line (pretty counter-intuitive!)
            //MultiLine means that my pattern is spread over more than one line, it allows my comments to appear on the right
            reLocation = new Regex(CountJobTags.Properties.Resources.locationPattern,
                                   RegexOptions.IgnorePatternWhitespace | RegexOptions.Multiline | RegexOptions.Singleline);

            string pageCountPattern = @"
                                        page		#literal
                                        \s			#Whitespace
                                        \d+			#digit, 1 or more
                                        \s			#whitespace
                                        of			#Literal
                                        \s+			#Whitespace, 1 or more
                                        (\d+)		#Capture group, 1 or more digits
                                        ";
            rePageMax = new Regex(pageCountPattern, RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace);
        }

        /// <summary>
        /// Parse string string paramter 'divText', which contains all the text for the job (which is wrapped in an Html div tag)
        /// </summary>
        /// <param name="reAge"></param>
        /// <param name="reTags"></param>
        /// <param name="reLocation"></param>
        /// <param name="jobId"></param>
        /// <param name="divText"></param>
        /// <returns></returns>
        private IEnumerable<JobDesc> ParseJobFromDiv(Regex reAge, Regex reTags, Regex reLocation, string jobId, string divText) {
            Match ageMatch = reAge.Match(divText);
            string age = "";
            if (ageMatch.Success) {
                age = ageMatch.Groups[1].Value;
            }

            int startPos = ageMatch.Index + ageMatch.Length;

            startPos += ageMatch.Length;
            string companyName = "", city = "", state = "", country = "";
            bool remoteAllowed = false;
            Match locMatch = reLocation.Match(divText, startPos);
            if (locMatch.Success) {
                companyName = locMatch.Groups[1].Value;
                string[] locParts = locMatch.Groups[2].Value.Split(',');
                if (locParts.Length > 0) {
                    city = locParts[0].Trim();
                    if (locParts.Length > 1) {
                        string token2 = locParts[1].Trim();
                        //if the location has two parts and the second one is a US state...
                        if (locParts.Length == 2 && Array.BinarySearch(STATES, token2) >= 0) {
                            country = "USA";
                            state = token2;
                        } else if (locParts.Length == 3) {
                            //Probably Canada, regardless, there are 3 parts to the name, assume city/province/country
                            country = locParts[2].Trim();
                            state = token2;
                        } else if (locParts.Length == 2) {
                            //2-part name that doesn't have a US state for 2nd entry
                            country = token2;
                        }
                    }
                }
                remoteAllowed = locMatch.Groups[3].Value.IndexOf("(allows remote)") >= 0;
            }

            startPos += locMatch.Length;
            Match tagMatch = reTags.Match(divText, startPos);
            startPos += locMatch.Length;
            //we'll return a value for every tag we find for this job
            while (tagMatch.Success) {
                JobDesc aJob = new JobDesc(tagMatch.Groups[1].Value.Replace("\t", "\0"), age, jobId, city, state, country, remoteAllowed);
                yield return aJob;

                tagMatch = tagMatch.NextMatch();
            }
        }

        /// <summary>
        /// Updates the progress bar and status text
        /// </summary>
        /// <param name="pageNum">What page we just finished procesing</param>
        /// <param name="pageTot">How many pages there are</param>
        private void UpdateProgess(int pageNum, int pageTot) {
            prg.Value = 100 * ((double)pageNum) / pageTot;
            StatusText = string.Format("Page {0}", pageNum);
        }

        /// <summary>
        /// Should be invoked when we have completed retrieving job tags from the web
        /// </summary>
        /// <remarks>
        /// Writes the results to a file and displays tag counts in the grid
        /// </remarks>
        private void FetchComplete() {
            try {
                string fName = TAG_FILE;
                if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed) {
                    if (string.IsNullOrWhiteSpace(System.Deployment.Application.ApplicationDeployment.CurrentDeployment.DataDirectory))
                        MessageBox.Show("No Data Directory Specifed");
                    else
                        fName = System.Deployment.Application.ApplicationDeployment.CurrentDeployment.DataDirectory + "\\" + TAG_FILE;
                }
                using (StreamWriter sw = File.CreateText(fName)) {
                    foreach (JobDesc jd in _JobTagList) {
                        sw.WriteLine(jd.ToString());
                    }
                }
                LoadTagFile(fName);
                _FetchingFromWeb = false;
            } catch (Exception ex) {
                MessageBox.Show(ex.Message + "\n\n" + ex.StackTrace, "Uh-Oh - Error", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }

        /// <summary>
        /// Loads the previously downoaded jobs file
        /// </summary>
        /// <param name="sender">The button</param>
        /// <param name="e">Event args</param>
        private void LoadFromFile(string fName) {
            Mouse.OverrideCursor = Cursors.Wait;
            LoadTagFile(fName);

            Mouse.OverrideCursor = null;
            FileInfo fi = new FileInfo(fName);
            StatusText = "File loaded; that file's last update: " + fi.LastWriteTime.ToString();
            if (grdCountsByTag.Focusable) grdCountsByTag.Focus();

        }

        /// <summary>
        /// Loads the tag file, which is tab-separated values
        /// <remarks>
        /// Builds member-level List '_TagListWithCounts'
        /// Invokes 
        /// </remarks>
        /// </summary>
        private void LoadTagFile(string fName) {
            try {
                List<TagAndCount> tagList = new List<TagAndCount>();
                TagAndCountIDComparer comp = new TagAndCountIDComparer();
                List<JobAndTags> jobTagsList = new List<JobAndTags>();
                string lastId = string.Empty;
                JobAndTags jobTags = new JobAndTags();
                TagAndCount tgCount = null;
                //Each line is one skill for a job, along with the job id and location
                //Example: "54758	ruby-on-rails	< 1 hour ago"
                using (StreamReader sr = File.OpenText(fName)) {
                    List<string> otherTags;
                    while (sr.Peek() != -1) {
                        string aLine = sr.ReadLine();
                        string curId = "";
                        tgCount = ParseTagFromLine(aLine, out curId);
                        int ndx = tagList.BinarySearch(tgCount, comp);
                        if (ndx < 0) {
                            otherTags = new List<string>();
                            tagList.Insert(~ndx, tgCount);
                        } else {
                            tagList[ndx].Count++;
                            tagList[ndx].JobList.Add(tgCount.JobList[0]);
                        }

                        if (lastId == curId) {
                            jobTags.Tags.Add(tgCount.Tag);
                        } else {
                            jobTags = new JobAndTags(curId, tgCount.Tag);
                            jobTagsList.Add(jobTags);
                        }
                        lastId = curId;
                    }
                }

                if (jobTags != null && tgCount != null) {
                    jobTags.Tags.Add(tgCount.Tag);
                }
                //Hold a copy for future filtering etc.
                _TagListWithCounts = tagList;
                double maxRelatedSKills = double.MinValue;
                double maxAge = double.MinValue;

                //Now build the related tag counts for each tag
                //For example, if the skill is WPF and the employer also wants C# and SQL Server, then C# and SQL Server are the related skills
                List<Dictionary<string, int>> associationDic = new List<Dictionary<string, int>>();
                foreach (TagAndCount aTag in _TagListWithCounts) {
                    int theCount = 0, totalListLength = 0;
                    foreach (var aJob in jobTagsList.Where(jtl => jtl.Tags.Contains(aTag.Tag))) {
                        foreach (string relatedTag in aJob.Tags) {
                            if (relatedTag != aTag.Tag) {
                                if (aTag.RelatedTagCounts.ContainsKey(relatedTag)) {
                                    aTag.RelatedTagCounts[relatedTag]++;
                                } else {
                                    aTag.RelatedTagCounts.Add(relatedTag, 1);
                                }
                            }
                        }
                        totalListLength += aJob.Tags.Count;
                        theCount++;
                    }
                    aTag.AvgNumRelatedSkills = theCount > 1 ? ((double)totalListLength - 1) / theCount : 0.0;
                    
                    //Keep track of the max number of related skills
                    if (aTag.AvgNumRelatedSkills > maxRelatedSKills)
                        maxRelatedSKills = aTag.AvgNumRelatedSkills;
                    //Same for job age:
                    //if (aTag.AvgAgeInHours > maxAge)
                    //    maxAge = aTag.AvgAgeInHours;
                }
                maxAge = _TagListWithCounts.Max(t => t.AvgAgeInHours);

                //Next, set the 'OtherJobAvgPct', which is inversely proportional to the average number of job skills
                //Because the fewer other skills desired, the more likely the employer is to be desperate, thus to pay higher
                foreach (TagAndCount aTag in _TagListWithCounts) {
                    aTag.OtherJobAvgPct =  (maxRelatedSKills - aTag.AvgNumRelatedSkills) / maxRelatedSKills;
                    //Presumably employers remove ads when the job is filled. Thelonger the ad is up, the more desperate they are, 
                    //thus the more pressure to make enticing offers
                    aTag.AvgJobAgePct = 1.0 -( (maxAge - aTag.AvgAgeInHours) / maxAge);
                }
                _AdjustableTagList = new List<TagAndCount>();
                foreach (TagAndCount tac in _TagListWithCounts)
                    _AdjustableTagList.Add((TagAndCount) tac.Clone());

                Dispatcher.Invoke(new ProcessTags(PopulateGridsAndCleanUp), tagList);
            } catch (Exception ex) {
                Console.WriteLine(ex.Message);
            }
        }

        /// <summary>
        /// Makes distinct lists for states/countries, cleans-up various things, updates the graph
        /// </summary>
        /// <param name="tagList">The tags generated by loading the file or else downloading from the web</param>
        private void PopulateGridsAndCleanUp(List<TagAndCount> tagList) {
            try {
                int grandTagCount = tagList.Sum(tg => tg.Count);

                //sort and update rank/pct
                tagList.Sort(new TagAndCountDefaultSortComparer());
                int rank = 1;
                foreach (TagAndCount tac in tagList) {
                    tac.Rank = rank++;
                    tac.Pct = ((double)tac.Count) / grandTagCount;
                }

                //We can use the max to normalize the bar graph item heights:
                int max = tagList.Count > 0 ? tagList.Max(t => t.Count) : 0;

                prg.Visibility = System.Windows.Visibility.Collapsed;

                updMinCount.Maximum = max - 1;
                List<SelectableCaption> states =
                                               tagList.SelectMany(c => c.JobList)
                                               .Select(j => j.Province.Trim())
                                               .Distinct()
                                               .Select(p => new SelectableCaption { Caption = string.IsNullOrEmpty(p) ? "Not Specified" : p, IsSelected = true })
                                               .OrderBy(p => p.Caption)
                                               .ToList<SelectableCaption>();
                cboStates.ItemsSource = states;
                _StateSelectionCount = cboStates.ItemsSource.Count();

                List<SelectableCaption> countries = tagList.SelectMany(t => t.JobList)
                                                .Select(j => j.Country.TrimEnd())
                                                .Distinct()
                                                .Select(c => new SelectableCaption { Caption = string.IsNullOrWhiteSpace(c) ? "Not Specified" : c, IsSelected = true })
                                                .OrderBy(c => c.Caption)
                                                .ToList<SelectableCaption>();
                cboCountry.ItemsSource = countries;

                UpdateGraph();
                grdCountsByTag.ItemsSource = tagList;
                this.EnableAnimations = true;
                tabGroup.Visibility = Visibility.Visible;
                btnApplyFilters.IsEnabled = false;
                if (_FetchingFromWeb) {
                    StatusText = "Fetch Complete";
                }
            } catch (Exception ex) {
                Console.WriteLine(ex.Message);
            } finally {
                btnFetch.Content = REFRESH_BTN_CAPTION;
                Mouse.OverrideCursor = null;
            }
        }

        /// <summary>
        /// Fires when user filters by a different Y-Axis choice
        /// </summary>
        /// <param name="sender">A radio button</param>
        /// <param name="e">Event args</param>
        private void Y_AxisRadioButton_Checked(object sender, RoutedEventArgs e) {
            if (btnApplyFilters != null) {
                btnApplyFilters.IsEnabled = true;
            }
        }

        /// <summary>
        /// Fires when user selects a different minimum value
        /// </summary>
        /// <param name="sender">The custom up-down control</param>
        /// <param name="e">Event args</param>
        private void updMinCount_ValueChanged(object sender, RoutedEventArgs e) {
            if (btnApplyFilters != null) {
                btnApplyFilters.IsEnabled = true;
            }
        }

        /// <summary>
        /// Fires when user changes the selction in a checkable combo box
        /// </summary>
        /// <param name="sender">The combo box in question</param>
        /// <param name="e">Event args</param>
        private void CheckableCombo_SelectionChanged(object sender, RoutedEventArgs e) {
            if (btnApplyFilters != null) {
                btnApplyFilters.IsEnabled = true;
            }
        }

        /// <summary>
        /// Parses a line from the file and builds a TagAndCount instance, the line should be tab-delimited
        /// </summary>
        /// <param name="aLine">Text to split</param>
        /// <param name="id">The web site's ID for the job</param>
        /// <returns>TagAndCount object</returns>
        public TagAndCount ParseTagFromLine(string aLine, out string id) {
            TagAndCount result = new TagAndCount();
            string[] tokens = aLine.Split('\t');

            JobDesc aJob = new JobDesc(tokens[1], tokens[2], tokens[0], tokens[3], tokens[4], tokens[5], bool.Parse(tokens[6]));
            result = new TagAndCount { Tag = tokens[1], JobList = new List<JobDesc> { aJob }, Count = 1 };
            id = tokens[0];
            return result;
        }

        /// <summary>
        /// Attempts to show hide the grid details in a non-stupid way
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btnShowHideDetails_Click(object sender, RoutedEventArgs e) {
            Button btn = null;
            btn = (Button)sender;
            btn.Content = ((string)btn.Content) == "+" ? "-" : "+";
            
            ShowHideDetails(sender);
        }

        /// <summary>
        /// Finds the row associated with the button that was clicked and force its details open for display
        /// </summary>
        /// <param name="sender">Hopefully a button</param>
        public void ShowHideDetails(object sender) {
            //Loop through the parents of caller until we either find a DataGridRow or exhaust the "parent tree"
            for (var vis = sender as Visual; vis != null; vis = VisualTreeHelper.GetParent(vis) as Visual) {
                if (vis is DataGridRow) {
                    var row = (DataGridRow)vis;
                    ((TagAndCount)row.Item).IsSelected = true;
                    if (row.DetailsVisibility == System.Windows.Visibility.Visible) {
                        row.DetailsVisibility = Visibility.Collapsed;
                        return;
                    } else {
                        row.DetailsVisibility = Visibility.Visible;
                        return;
                    }
                }
            }
        }

        /// <summary>
        /// Tries to give user feedback when the second tab is displayed
        /// </summary>
        /// <param name="sender">Tab control</param>
        /// <param name="e">Event args</param>
        private void tabGroup_SelectionChanged(object sender, SelectionChangedEventArgs e) {
            if (((TabControl)sender).SelectedIndex == 1) {
                StatusText = "Tip: change Y-Axis choices or apply filters, then click 'Apply'";
            }
        }

        /// <summary>
        /// Fires when user clicks the button apply filters
        /// </summary>
        /// <param name="sender">The button</param>
        /// <param name="e">Event args</param>
        private void btnApplyFilters_Click(object sender, RoutedEventArgs e) {
            btnApplyFilters.IsEnabled = false;
            StatusText = "Updating graph...";
            Mouse.OverrideCursor = Cursors.Wait;
            UpdateGraph();
            Mouse.OverrideCursor = null;
            StatusText = "Display updated";
        }
        
        /// <summary>
        /// Updates the graph with users' filters and Y-Axis choices
        /// </summary>
        /// <remarks>
        /// Side-effects: 
        ///     _TagListWithCounts entries are modified with filter function and other property changes
        ///     lstTagsGraph has its ItemsSource set
        /// </remarks>
        private void UpdateGraph() {
            if (_AdjustableTagList == null) return;
            lstTagsGraph.ItemsSource = null;
            //The states/provinces are already sorted
            List<string> states = cboStates.SelectedCaptions;
            bool statesNotUsed = states.Count == cboStates.ItemsSource.Count();

            //The countries are already sorted, and the algorithm relies on that fact
            List<string> countries = cboCountry.SelectedCaptions;
            bool countriesNotUsed = countries.Count == cboCountry.ItemsSource.Count();
            bool dontCareRemote = rdoRemoteDontCare.IsChecked ?? false;
            bool remoteAllowed = rdoRemoteYes.IsChecked ?? false;

            List<TagAndCount> source = _AdjustableTagList.Where(t => t.JobList.Count >= updMinCount.Value).ToList<TagAndCount>();

            //The filter function is used to calculate counts, etc.
            Func<JobDesc, bool> filter = j => (dontCareRemote || (j.AllowsRemote == remoteAllowed))
                                              &&
                                              (statesNotUsed || states.BinarySearch(j.Province) >= 0)
                                              &&
                                              (countriesNotUsed || countries.BinarySearch(j.Country) >= 0);
            foreach (TagAndCount tac in _AdjustableTagList) {
                tac.Filter = filter;
            }

            double maxHourTot = double.MinValue, maxPctAllowingRemote = double.MinValue, maxAvgOtherSKill = double.MinValue;
            int maxTagCount = int.MinValue;


            //In order to properly scale the graph, calculate max for our 3 candiate Y-Axis columns, e.g. age/count/pctAllowingRemote
            foreach (TagAndCount tac in source) {
                if (tac.AvgAgeInHours > maxHourTot) maxHourTot = tac.AvgAgeInHours;
                if (tac.Count > maxTagCount) maxTagCount = tac.Count;
                if (tac.PctAllowingRemote > maxPctAllowingRemote) maxPctAllowingRemote = tac.PctAllowingRemote;
                if (tac.AvgNumRelatedSkills > maxAvgOtherSKill) maxAvgOtherSKill = tac.AvgNumRelatedSkills;
            }

            //Set the Y-Value for each TagAndCount instance using the max values set above, according to which radio button
            //user has selected
            Random myRand = new Random();
            if (rdoCount.IsChecked ?? false) {
                foreach (TagAndCount t in source) {
                    int colorIndex = myRand.Next(_GraphColors.Length);
                    t.SetYValue(maxTagCount, YValueField.Count, BAR_HEIGHT);
                    t.BrushColor = _GraphColors[colorIndex];
                }
            } else if (rdoAvgAge.IsChecked ?? false) {
                foreach (TagAndCount t in source) {
                    t.SetYValue(maxHourTot, YValueField.AvgHours, BAR_HEIGHT);
                }
            } else if (rdoPctRemote.IsChecked ?? false) {
                foreach (TagAndCount t in source) {
                    t.SetYValue(maxPctAllowingRemote, YValueField.PctAllowingRemote, BAR_HEIGHT);
                }
            } else {
                foreach (TagAndCount t in source) {
                    t.SetYValue(maxAvgOtherSKill, YValueField.AvgOtherSkillCount, BAR_HEIGHT);
                }
            }
            lstTagsGraph.ItemsSource = source.Where(t => t.Count > 0).OrderByDescending(t => t.YValue).ThenBy(t => t.Tag);
        }

        /// <summary>
        /// Finds the users' selected tag in the grid
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btnTagSearch_Click(object sender, RoutedEventArgs e) {
            string target = txtTagName.Text.ToLower();
            List<TagAndCount> hits = grdCountsByTag.Items
                                    .Cast<TagAndCount>()
                                    .Where(t => t.Tag.ToLower().Contains(target))
                                    .ToList<TagAndCount>();
            if (hits.Count > 0) {
                if (_LastSearch == target) {
                    if (++_LastSearchIndex >= hits.Count) {
                        _LastSearchIndex = 0;
                    }
                } else {
                    _LastSearchIndex = 0;
                }
                grdCountsByTag.ScrollIntoView(hits[_LastSearchIndex]);
                grdCountsByTag.SelectedItem = hits[_LastSearchIndex];
                StatusText = string.Format("Displaying match {0} of {1} for {2}", _LastSearchIndex + 1, hits.Count, target);
            } else {
                StatusText = "No matches";
                MessageBox.Show("No matches in the Tag column for " + target, "Search Failed", MessageBoxButton.OK, MessageBoxImage.Exclamation);
            }

            _LastSearch = target;
        }

        /// <summary>
        /// Attempts to display a helpful hint
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void ContextMenu_Opened(object sender, RoutedEventArgs e) {
            StatusText = "Suggestion: use 'Invert Selection' for quick access'";
        }

        /// <summary>
        /// Updates the tool tip/ status text informing user how many are selected
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void checkableComboBox_LostFocus(object sender, FocusChangeEventArgs e) {
            CheckableComboBox cbo = sender as CheckableComboBox;
            int selCount = 0;
            if (cbo != null) {
                selCount = cbo.SelectedCaptions.Count;
                if (selCount == cbo.ItemsSource.Count()) {
                    cbo.ToolTip = "Check/unckeck states for filtering purposes";
                } else {
                    if (cbo == cboStates) {
                        if (selCount != _StateSelectionCount) {
                            _StateSelectionCount = selCount;
                            StatusText = String.Format("Filtering on {0} states", selCount);
                            cbo.ToolTip = String.Format("Filtering on {0} states", selCount);
                        }
                    } else {
                        if (selCount != _CountrySelectionCount) {
                            _CountrySelectionCount = selCount;
                            StatusText = String.Format("Filtering on {0} countries", selCount);
                        }
                    }
                }
            }
        }

        /// <summary>
        /// Fires when the timer goes off, flips the flag for EnableAnimations
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void _timer_Tick(object sender, EventArgs e) {
            EnableAnimations = false;
            _timer.Stop();
        }

        /// <summary>
        /// The checkable combo box raises an event when it has a suggestion, here display it in the status bar
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void checkableCombo_UsageSuggestionChanged(object sender, SuggestionEventArgs e) {
            StatusText = e.Suggestion;
        }

        /// <summary>
        /// Fired when user changes
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void txtTagName_TextChanged(object sender, TextChangedEventArgs e) {
            EnableSkillSearch = txtTagName.Text.Trim().Length > 0;
        }

        private bool _EnableAnimations;
        /// <summary>
        /// Attempts to turn-on/off the status bar animations
        /// </summary>
        public bool EnableAnimations {
            get { return _EnableAnimations; }
            set {
                _EnableAnimations = value;
                if (PropertyChanged != null) {
                    PropertyChanged(this, new PropertyChangedEventArgs("EnableAnimations"));
                }
                _timer.Start();
            }
        }

        private string _StatusText;
        /// <summary>
        /// Designed for binding to a textblock
        /// </summary>
        public string StatusText {
            get { return _StatusText; }
            set { 
                _StatusText = value;
                if (PropertyChanged != null) {
                    PropertyChanged(this, new PropertyChangedEventArgs("StatusText"));
                }
                if (!_FetchingFromWeb) {
                    EnableAnimations = true;
                }
            }
        }
        private bool _EnableSkillSearch;
        /// <summary>
        /// Suitable for controling whether the search button is enbled
        /// </summary>
        public bool EnableSkillSearch {
            get { return _EnableSkillSearch; }
            set { 
                _EnableSkillSearch = value;
                if (PropertyChanged != null) {
                    PropertyChanged(this, new PropertyChangedEventArgs("EnableSkillSearch"));
                }
            }
        }

        /// <summary>
        /// Bar chart bar max height
        /// </summary>
        public int MaxBarHeight {
            get { return BAR_HEIGHT; }
        }
    }
}
